
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
buildjob pushes the image, thedeployjob 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.0registry.gitlab.com/mon-groupe/mon-app:latestregistry.gitlab.com/mon-groupe/mon-app/api:v3Automatic 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:
| Variable | Value | Usage |
|---|---|---|
CI_REGISTRY | registry.gitlab.com | Registry address |
CI_REGISTRY_USER | gitlab-ci-token | User (temporary token) |
CI_REGISTRY_PASSWORD | Token of the current job | Password (expires with the job) |
CI_REGISTRY_IMAGE | registry.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.
| Criterion | DinD | kaniko (maintained fork) | Buildah |
|---|---|---|---|
privileged mode required | Yes | No | No |
| Runner tuning (Docker executor) | privileged = true | None, unless the runner drops all capabilities | security_opt with seccomp and AppArmor relaxed |
| Docker daemon needed | Yes | No | No |
| Output format | Docker | OCI or Docker | OCI or Docker |
| GitLab documentation | Yes | Removed | Yes |
| Typical case | gitlab.com hosted runners, disposable dedicated runner | Standard Docker runner | Dedicated 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"| Strategy | Variable | Example | Usage |
|---|---|---|---|
| Commit SHA | $CI_COMMIT_SHORT_SHA | a1b2c3d4 | Exact traceability |
| Branch | $CI_COMMIT_REF_SLUG | feature-auth | Staging, review apps |
| Git tag | $CI_COMMIT_TAG | v1.2.0 | Production, releases |
latest | Not applicable | latest | Local development |
Automatic cleanup (cleanup policies)
Without cleanup, the Container Registry grows indefinitely. GitLab offers automatic cleanup policies:
- Go to Settings > Packages and registries > Container Registry
- Enable Cleanup policy
- Configure the rules:
| Parameter | Recommendation | Effect |
|---|---|---|
| Expiration policy | Enabled | Cleans up according to the rules below |
| Older than | 90 days | Deletes images older than 90 days |
| Keep n tags | 10 | Always 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:
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:
| Format | Use case | Publish command |
|---|---|---|
| npm | JavaScript/TypeScript libraries | npm publish |
| Maven | Java artifacts | mvn deploy |
| PyPI | Python packages | twine upload |
| NuGet | .NET libraries | dotnet nuget push |
| Go | Go modules | Native proxy |
| Composer | PHP packages | Via API |
| Conan | C/C++ libraries | conan upload |
| Helm | Kubernetes charts | helm push |
| Generic | Any binary file | Via 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.
-
Configure
.npmrcin 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} -
Add the scope in
package.json:{"name": "@mon-scope/ma-lib","version": "1.0.0"} -
Publish from the pipeline:
publish-npm:stage: deployimage: node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2script:- npm publishrules:- 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_TAGWith 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 type | Limit per 6 hours |
|---|---|
| Unauthenticated | 100 pulls per IPv4 address (or IPv6 /64 subnet) |
| Personal (authenticated) | 200 pulls |
| Pro / Team / Business | Unlimited |
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.
-
Enable it at the group level: Settings > Packages and registries > Dependency Proxy
-
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 -
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:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2script:- npm testlint:image: ${DOCKER_IMAGE_PREFIX}/python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710descript:- 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 committest: image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA script: - pytest
# Image from another project of the same grouplint: 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 allowlistlint: 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 projectDOCKER_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.
| Symptom | Likely cause | Solution |
|---|---|---|
denied: access forbidden on push | Runner not authenticated | Check docker login with CI_REGISTRY_* |
error: toomanyrequests | Docker Hub quota reached | Enable the Dependency Proxy or authenticate Docker Hub |
| Image pushed but invisible in the UI | Registry disabled on the project | Settings > General > Visibility > Container Registry |
manifest unknown on pull | Tag does not exist or cleanup policy too aggressive | Check the "keep" regex in the cleanup policies |
Buildah build fails with STORAGE_DRIVER | Overlay driver not supported in the container | Use STORAGE_DRIVER: vfs and BUILDAH_ISOLATION: chroot |
Buildah: unshare(CLONE_NEWUSER): Operation not permitted | Default seccomp and AppArmor profiles of the runner | Dedicated build runner with relaxed security_opt, or kaniko |
kaniko: chown /etc/gshadow: operation not permitted | Runner 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 DinD | Runner with privileged = false | Dedicated runner with privileged = true, or kaniko |
npm package 403 Forbidden | Missing scope or misconfigured .npmrc | Check 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_USERandCI_REGISTRY_PASSWORD: you still have to pass them to the tool (docker login,buildah loginor the kanikoconfig.json) - Without
privilegedmode, 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).