Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

GitLab registries: build and publish images and packages

  • 25 min

By updated

Improve this page

GitLab logo

Do your CI jobs build Docker images, but you push them by hand to Docker Hub? Do your docker pull commands fail with an HTTP 429 error because Docker Hub limits downloads? GitLab integrates two registries at the project level, the Container Registry for Docker images and the Package Registry for application packages, as well as a Dependency Proxy at the group level to cache Docker Hub images. This guide shows you how to use them concretely in your pipelines.

What you will learn

  • Build and push a Docker image from a GitLab CI pipeline
  • Publish a package to npm or Maven in the Package Registry
  • Configure the Dependency Proxy to avoid Docker Hub quotas
  • Automatically clean up old images with cleanup policies
  • Choose between DinD, kaniko and Buildah depending on what your runner allows

In which context?

As soon as your pipeline produces a deliverable, a Docker image, an npm package, a Maven artifact, you need a reliable place to store it, version it and distribute it. Without a built-in registry, teams end up:

  • Pushing by hand to Docker Hub with shared credentials
  • Hitting the Docker Hub quotas (100 pulls per 6 hours without authentication, 200 with a Personal account)
  • Losing traceability between the source code and the deployed image
  • Multiplying external tools (Nexus, Harbor, Artifactory) for a need that GitLab covers natively

In daily administration, the GitLab registries are used to:

  • Deploy from CI: the build job pushes the image, the deploy job pulls it, everything stays in GitLab
  • Share base images between projects of the same group
  • Distribute internal packages (libraries, CLI tools) without publishing on npmjs.com
  • Work around Docker Hub limits through the group's Dependency Proxy

Prerequisites

  • A GitLab project with a working pipeline (see First pipeline (in French))
  • Docker installed locally to test the builds (see Docker: first steps (in French))
  • Understand CI/CD variables (in French) (the CI_REGISTRY_* variables are injected automatically)
  • A runner with the Docker or Shell executor (see Runners (in French))

The GitLab Container Registry

What it is

The Container Registry is a Docker image registry (OCI-compatible) built into every GitLab project. It is enabled by default on GitLab.com and on properly configured self-managed instances.

Each image is stored under a predictable URL:

registry.gitlab.com/<namespace>/<projet>[/<sous-chemin>]:<tag>

For example, for a project mon-groupe/mon-app:

registry.gitlab.com/mon-groupe/mon-app:1.2.0
registry.gitlab.com/mon-groupe/mon-app:latest
registry.gitlab.com/mon-groupe/mon-app/api:v3

Automatic authentication variables

In a GitLab CI pipeline, four variables related to the registry are injected automatically into every job, without any configuration on your part:

VariableValueUsage
CI_REGISTRYregistry.gitlab.comRegistry address
CI_REGISTRY_USERgitlab-ci-tokenUser (temporary token)
CI_REGISTRY_PASSWORDToken of the current jobPassword (expires with the job)
CI_REGISTRY_IMAGEregistry.gitlab.com/<namespace>/<projet>Full image URL

You never need to store credentials in your CI/CD variables to push to your project's Container Registry.

Build and push with Docker-in-Docker (DinD)

The classic method uses the docker:dind service (Docker-in-Docker) to have a Docker daemon inside the job:

stages:
- build
build-image:
stage: build
image: docker:27@sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce
services:
- docker:27-dind@sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
script:
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$CI_REGISTRY_IMAGE:latest" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
- docker push "$CI_REGISTRY_IMAGE:latest"

Check: after the push, go to Deploy > Container Registry in your GitLab project. You should see your image with both tags.

Build without DinD: kaniko or Buildah

A daemonless builder reads the Dockerfile and produces the image without starting a Docker daemon: no more need for privileged mode. But "without privilege" does not mean "without tuning". On a runner with the Docker executor left with its default security profiles, building the Dockerfile of the running example Pipeline Craft (in French) gives two opposite results, which the GitLab documentation does not spell out.

kaniko builds the image without any tuning of the runner. Google's original repository has been archived since June 2025, but the project continues in a maintained fork, osscontainertools/kaniko, whose -debug image ships the shell that GitLab needs to run script::

build-image-kaniko:
stage: build
image:
name: ghcr.io/osscontainertools/kaniko:v1.28.6-debug@sha256:7ab44d0131c3051b6dea128067e258c7afb5c4815082a93b9cf6c624f2140bf0
entrypoint: [""]
before_script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(printf "%s:%s" "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
script:
- /kaniko/executor
--context "$CI_PROJECT_DIR"
--dockerfile "$CI_PROJECT_DIR/Dockerfile"
--destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

entrypoint: [""] is mandatory: without it, GitLab runs the entrypoint of the image instead of your script. The before_script writes the registry credentials to /kaniko/.docker/config.json from the CI_REGISTRY_* variables: without this file, the push fails with UNAUTHORIZED: authentication required. One limit to know: on a hardened runner that drops all Linux capabilities (cap_drop = ["ALL"]), kaniko fails with chown /etc/gshadow: operation not permitted; it needs at least CHOWN, DAC_OVERRIDE and FOWNER for this Dockerfile, as the guide Harden your pipelines (in French) shows.

Buildah, for its part, fails with the default profiles: Error during unshare(CLONE_NEWUSER): Operation not permitted. It only works by relaxing the seccomp and AppArmor profiles of the container, two filters that limit what a container can ask of the kernel. This tuning is done in the config.toml of a runner dedicated to builds, never of a shared runner:

[[runners]]
executor = "docker"
[runners.docker]
privileged = false
security_opt = ["seccomp:unconfined", "apparmor:unconfined"]

With this runner, the following Buildah job builds and pushes the image. STORAGE_DRIVER: vfs and BUILDAH_ISOLATION: chroot avoid the overlay mounts and the namespaces that a container cannot create:

build-image-buildah:
stage: build
image:
name: quay.io/buildah/stable:v1.42.0@sha256:239c3349daf9932c29d0d3e74393a180ae8a70c1f1db685a599a160cae01aeb8
variables:
STORAGE_DRIVER: vfs
BUILDAH_FORMAT: docker
BUILDAH_ISOLATION: chroot
before_script:
- echo "$CI_REGISTRY_PASSWORD" | buildah login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
script:
- buildah bud
--tag "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
--tag "${CI_REGISTRY_IMAGE}:latest"
.
- buildah push "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
- buildah push "${CI_REGISTRY_IMAGE}:latest"

Removing seccomp and AppArmor remains a loss of protection, smaller than privileged but real: for rootless BuildKit, which has the same need, the GitLab documentation advises against seccomp:unconfined and recommends a custom seccomp profile. On Kubernetes or OpenShift, GitLab documents a rootless Buildah tutorial specific to those platforms.

Which method to choose? The table sums up what each method requires from the runner, the criterion that decides in practice. None of them makes untrusted code safe: all of them run the RUN instructions of the Dockerfile.

CriterionDinDkaniko (maintained fork)Buildah
privileged mode requiredYesNoNo
Runner tuning (Docker executor)privileged = trueNone, unless the runner drops all capabilitiessecurity_opt with seccomp and AppArmor relaxed
Docker daemon neededYesNoNo
Output formatDockerOCI or DockerOCI or Docker
GitLab documentationYesRemovedYes
Typical casegitlab.com hosted runners, disposable dedicated runnerStandard Docker runnerDedicated build runner, Kubernetes or OpenShift

Tag images with a strategy

A good tagging system makes deployment and rollback easier:

build-image:
stage: build
image: docker:27@sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce
services:
- docker:27-dind@sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
script:
- |
# Build only once
docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
# Add extra tags without rebuilding
docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
# Semantic tag (if we are building a Git tag)
if [ -n "$CI_COMMIT_TAG" ]; then
docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
fi
docker push --all-tags "$CI_REGISTRY_IMAGE"
StrategyVariableExampleUsage
Commit SHA$CI_COMMIT_SHORT_SHAa1b2c3d4Exact traceability
Branch$CI_COMMIT_REF_SLUGfeature-authStaging, review apps
Git tag$CI_COMMIT_TAGv1.2.0Production, releases
latestNot applicablelatestLocal development

Automatic cleanup (cleanup policies)

Without cleanup, the Container Registry grows indefinitely. GitLab offers automatic cleanup policies:

  1. Go to Settings > Packages and registries > Container Registry
  2. Enable Cleanup policy
  3. Configure the rules:
ParameterRecommendationEffect
Expiration policyEnabledCleans up according to the rules below
Older than90 daysDeletes images older than 90 days
Keep n tags10Always keeps the last 10 tags
Name regex (keep)^v\d+\.\d+\.\d+$Protects semantic tags
Name regex (remove).*Deletes everything that is not protected

You can also trigger the cleanup through the API:

Terminal window
curl --request DELETE \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/registry/repositories/$REPO_ID/tags/old-tag"

The GitLab Package Registry

What it is

The Package Registry lets you publish and consume application packages directly in GitLab. It supports:

FormatUse casePublish command
npmJavaScript/TypeScript librariesnpm publish
MavenJava artifactsmvn deploy
PyPIPython packagestwine upload
NuGet.NET librariesdotnet nuget push
GoGo modulesNative proxy
ComposerPHP packagesVia API
ConanC/C++ librariesconan upload
HelmKubernetes chartshelm push
GenericAny binary fileVia HTTP API

Publish an npm package from CI

Publishing takes three gestures: tell npm where the GitLab registry is, declare the package name with its scope (the @mon-scope prefix), then run the publication from a job.

  1. Configure .npmrc in your project:

    @mon-scope:registry=https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/
    //gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${CI_JOB_TOKEN}
  2. Add the scope in package.json:

    {
    "name": "@mon-scope/ma-lib",
    "version": "1.0.0"
    }
  3. Publish from the pipeline:

    publish-npm:
    stage: deploy
    image: node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2
    script:
    - npm publish
    rules:
    - if: $CI_COMMIT_TAG

Check: go to Deploy > Package Registry in your project. You should see your package with the matching version.

Publish a Maven package from CI

The principle is the same: the job runs mvn deploy, and Maven authenticates to GitLab with the job token. This token goes through an HTTP header Job-Token, declared in a Maven configuration file, ci_settings.xml, shown right after the job.

publish-maven:
stage: deploy
image: maven:3.9-eclipse-temurin-21@sha256:c2a2c58516d160f43b50f12baa427ca86989e0bc942609e04aff61da5d9a7d74
script:
- mvn deploy -s ci_settings.xml
rules:
- if: $CI_COMMIT_TAG

With a ci_settings.xml file:

<settings>
<servers>
<server>
<id>gitlab-maven</id>
<configuration>
<httpHeaders>
<property>
<name>Job-Token</name>
<value>${CI_JOB_TOKEN}</value>
</property>
</httpHeaders>
</configuration>
</server>
</servers>
</settings>

The Dependency Proxy

What it is

The Dependency Proxy is a transparent cache/mirror for Docker Hub, enabled at the GitLab group level. When a runner pulls a public image (alpine, node, python), the proxy caches it in GitLab. Later pulls are served from the cache, without touching Docker Hub.

Why it is critical

Docker Hub applies download quotas computed over a sliding 6-hour window:

Account typeLimit per 6 hours
Unauthenticated100 pulls per IPv4 address (or IPv6 /64 subnet)
Personal (authenticated)200 pulls
Pro / Team / BusinessUnlimited

On a shared runner with several projects, the limit of 100 pulls per 6 hours (per IP address) is reached quickly. The Dependency Proxy works around this problem by serving the images from the GitLab cache.

Configure the Dependency Proxy

All you need is to enable the proxy on the group, then prefix the image names with the proxy address in your jobs.

  1. Enable it at the group level: Settings > Packages and registries > Dependency Proxy

  2. Use the proxy URL in your pipelines:

    # Before (Docker Hub direct, subject to quotas)
    image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
    # After (through the Dependency Proxy, group cache)
    image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
  3. For all the images of a pipeline, use a global variable:

    variables:
    DOCKER_IMAGE_PREFIX: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}
    test:
    image: ${DOCKER_IMAGE_PREFIX}/node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2
    script:
    - npm test
    lint:
    image: ${DOCKER_IMAGE_PREFIX}/python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
    script:
    - ruff check .

Check: after a first pull, go to Operate > Dependency Proxy at the group level. You should see the cached images.

Use Container Registry images in your pipelines

Once your images are built and pushed, use them as base images in other jobs or projects:

# Image from the same project, built by this commit
test:
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
script:
- pytest
# Image from another project of the same group
lint:
image: registry.gitlab.com/mon-groupe/outils-ci/linter:2.1.0
script:
- run-linter .

To pull an image from another project, there are two options:

Option 1, CI_JOB_TOKEN + allowlist (recommended): the token of the current job can access another project's registry if that project explicitly allows it in its job token allowlist (Settings > CI/CD > Token Access). No extra secret to manage:

# Nothing special on the pipeline side, the runner authenticates automatically
# As long as the source project has added your project to its job token allowlist
lint:
image: registry.gitlab.com/mon-groupe/outils-ci/linter:2.1.0
script:
- run-linter .

Option 2, Deploy Token: for stable access independent of pipelines (pulls from Docker Desktop, external scripts), create a Deploy Token with the read_registry permission in the source project:

# In the CI/CD variables of the consuming project
DOCKER_AUTH_CONFIG: >
{"auths":{"registry.gitlab.com":{"auth":"<base64(username:token)>"}}}

Troubleshooting

The table follows the order of the guide: read the error message of the job, find it in the left column, then apply the matching solution.

SymptomLikely causeSolution
denied: access forbidden on pushRunner not authenticatedCheck docker login with CI_REGISTRY_*
error: toomanyrequestsDocker Hub quota reachedEnable the Dependency Proxy or authenticate Docker Hub
Image pushed but invisible in the UIRegistry disabled on the projectSettings > General > Visibility > Container Registry
manifest unknown on pullTag does not exist or cleanup policy too aggressiveCheck the "keep" regex in the cleanup policies
Buildah build fails with STORAGE_DRIVEROverlay driver not supported in the containerUse STORAGE_DRIVER: vfs and BUILDAH_ISOLATION: chroot
Buildah: unshare(CLONE_NEWUSER): Operation not permittedDefault seccomp and AppArmor profiles of the runnerDedicated build runner with relaxed security_opt, or kaniko
kaniko: chown /etc/gshadow: operation not permittedRunner that drops all capabilities (cap_drop = ["ALL"])Give CHOWN, DAC_OVERRIDE and FOWNER back to the build runner
Cannot connect to the Docker daemon with DinDRunner with privileged = falseDedicated runner with privileged = true, or kaniko
npm package 403 ForbiddenMissing scope or misconfigured .npmrcCheck that @scope matches the GitLab namespace

Key points

  • The Container Registry is built into every GitLab project, no need for Docker Hub for your own images
  • The registry credentials are provided to each job by CI_REGISTRY_USER and CI_REGISTRY_PASSWORD: you still have to pass them to the tool (docker login, buildah login or the kaniko config.json)
  • Without privileged mode, kaniko (maintained fork) builds on a standard Docker runner, Buildah requires relaxed seccomp and AppArmor profiles: choose according to what your runner allows
  • The Dependency Proxy caches Docker Hub images at the group level, essential in the face of quotas (100 pulls/6h unauthenticated)
  • The Package Registry supports npm, Maven, PyPI, NuGet, Go, Helm and many other formats
  • Cleanup policies keep your registry from growing indefinitely, protect your production tags
  • Cross-project access favors CI_JOB_TOKEN + allowlist; Deploy Tokens are for access outside pipelines

Put it into practice

Apply this guide in Lab 09, Publish a registry (in French).

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn